8.2.1 单体应用容器化

在接下来的内容中,本书会向读者逐步展示如何将一个简单的单节点Node.js Web应用容器化。如果是Windows操作系统的话,处理过程也是大同小异。在本书接下来的版本中,会增加一个Windows环境下单体应用容器化的示例。

接下来通过以下几个步骤,来介绍具体的过程。

(1)获取应用代码。

(2)分析Dockerfile。

(3)构建应用镜像。

(4)运行该应用。

(5)测试应用。

(6)容器应用化细节。

(7)生产环境中的 多阶段构建

(8)最佳实践。

虽然本章将指导读者完成单节点应用的容器化,但在接下来的章节中读者可以了解到如何采用Docker Compose去完成多节点应用容器化。之后,本书会继续指导读者使用Docker Stack去处理更复杂应用的容器化场景。

1.获取应用代码

应用代码可以从我的GitHub主页获取,读者需要从GitHub将代码克隆到本地。

克隆操作会创建一个名为psweb的文件夹。可以进入该文件夹,并查看其中的内容。

$ cd psweb
$ ls -l
total 28
-rw-r--r-- 1 root root  341 Sep 29 16:26 app.js
-rw-r--r-- 1 root root  216 Sep 29 16:26 circle.yml
-rw-r--r-- 1 root root  338 Sep 29 16:26 Dockerfile
-rw-r--r-- 1 root root  421 Sep 29 16:26 package.json
-rw-r--r-- 1 root root  370 Sep 29 16:26 README.md
drwxr-xr-x 2 root root 4096 Sep 29 16:26 test
drwxr-xr-x 2 root root 4096 Sep 29 16:26 views

该目录下包含了全部的应用源码,以及包含界面和单元测试的子目录。这个应用结构非常简单,读者可以很方便地理解其源码内容。本章暂时不会涉及单元测试相关的内容。

目前应用的代码已就绪,接下来分析一下Dockerfile的具体内容。

2.分析Dockfile

在代码目录当中,有个名称为 Dockerfile 的文件。这个文件包含了对当前应用的描述,并且能指导Docker完成镜像的构建。

在Docker当中,包含应用文件的目录通常被称为构建上下文(Build Context)。通常将Dockerfile放到构建上下文的根目录下。

另外很重要的一点是,文件开头字母是大写 D ,这里是一个单词。像“dockerfile”或者“Docker file”这种写法都是不允许的。

接下来了解一下Dockerfile文件当中都包含哪些具体内容。

$ cat Dockerfile
FROM alpine
LABEL maintainer="nigelpoulton@hotmail.com"
RUN apk add --update nodejs nodejs-npm
COPY . /src
WORKDIR /src
RUN npm install
EXPOSE 8080
ENTRYPOINT ["node", "./app.js"]

Dockerfile主要包括两个用途。

  • 对当前应用的描述。
  • 指导Docker完成应用的容器化(创建一个包含当前应用的镜像)。

不要因Dockerfile就是一个描述文件而对其有所轻视!Dockerfile能实现开发和部署两个过程的无缝切换。同时Dockerfile还能帮助新手快速熟悉这个项目。Dockerfile对当前的应用及其依赖有一个清晰准确的描述,并且非常容易阅读和理解。因此,要像重视你的代码一样重视这个文件,并且将它纳入到源控制系统当中。

下面是这个文件中的一些关键步骤概述:以 alpine 镜像作为当前镜像基础,指定维护者(maintainer)为“nigelpoultion@hotmail.com”,安装 Node.jsNPM ,将应用的代码复制到镜像当中,设置新的工作目录,安装依赖包,记录应用的网络端口,最后将 app.js 设置为默认运行的应用。

具体分析一下每一步的作用。

每个Dockerfile文件第一行都是 FROM 指令。 FROM 指令指定的镜像,会作为当前镜像的一个基础镜像层,当前应用的剩余内容会作为新增镜像层添加到基础镜像层之上。本例中的应用基于Linux操作系统,所以在 FROM 指令当中所引用的也是一个Linux基础镜像;如果读者要容器化的应用是一个基于Windows操作系统的应用,就需要指定一个像 microsoft/aspnetcore-build 这样的Windows基础镜像了。

截至目前,基础镜像的结构如图8.2所示。

37.png

图8.2 基础镜像的结构

接下来,Dockerfile中通过标签(LABLE)方式指定了当前镜像的维护者为“nigelpoulton@hotmail. com”。每个标签其实是一个键值对(Key-Value),在一个镜像当中可以通过增加标签的方式来为镜像添加自定义元数据。备注维护者信息有助于为该镜像的潜在使用者提供沟通途径,这是一种值得提倡的做法。

注:

本书例子中的镜像并不会长期维护,这里只是为了向读者展示标签的最佳使用方式。

RUN apk add --update nodejs nodejs-npm 指令使用 alpineapk 包管理器将 nodejsnodejs-npm 安装到当前镜像之中。 RUN 指令会在 FROM 指定的 alpine 基础镜像之上,新建一个镜像层来存储这些安装内容。当前镜像的结构如图8.3所示。

COPY. / src 指令将应用相关文件从构建上下文复制到了当前镜像中,并且新建一个镜像层来存储。 COPY 执行结束之后,当前镜像共包含3层,如图8.4所示。

38.png

图8.3 当前镜像的结构

39.png

图8.4 当前的3层镜像

下一步,Dockerfile通过 WORKDIR 指令,为Dockerfile中尚未执行的指令设置工作目录。该目录与镜像相关,并且会作为元数据记录到镜像配置中,但不会创建新的镜像层。

然后, RUN npm install 指令会根据 package.json 中的配置信息,使用 npm 来安装当前应用的相关依赖包。 npm 命令会在前文设置的工作目录中执行,并且在镜像中新建镜像层来保存相应的依赖文件。目前镜像一共包含4层,如图8.5所示。

40.png

图8.5 当前的4层镜像

因为当前应用需要通过TCP端口8080对外提供一个Web服务,所以在Dockerfile中通过 EXPOSE 8080 指令来完成相应端口的设置。这个配置信息会作为镜像的元数据被保存下来,并不会产生新的镜像层。

最终,通过 ENTRYPOINT 指令来指定当前镜像的入口程序。 ENTRYPOINT 指定的配置信息也是通过镜像元数据的形式保存下来,而不是新增镜像层。

3.容器化当前应用/构建具体的镜像

到目前为止,读者应该已经了解基本的原理和流程,接下来是时候尝试构建自己的镜像了!

下面的命令会构建并生成一个名为 web:latest 的镜像。命令最后的点(.)表示Docker在进行构建的时候,使用当前目录作为构建上下文。

一定要在命令最后包含这个点,并且在执行命令前,读者要确认当前目录是psweb(包含Dockerfile和应用代码的目录)。

$ docker image build -t web:latest .   << don't forget the period (.)
Sending build context to Docker daemon  76.29kB
Step 1/8 : FROM alpine
latest: Pulling from library/alpine
ff3a5c916c92: Pull complete
Digest: sha256:7df6db5aa6...0bedab9b8df6b1c0
Status: Downloaded newer image for alpine:latest
 ---> 76da55c8019d
Step 8/8 : ENTRYPOINT node ./app.js
 ---> Running in 13977a4f3b21
 ---> fc69fdc4c18e
Removing intermediate container 13977a4f3b21
Successfully built fc69fdc4c18e
Successfully tagged web:latest

命令执行结束后,检查本地Docker镜像库是否包含了刚才构建的镜像。

$ docker image ls
REPO    TAG       IMAGE ID          CREATED              SIZE
web     latest    fc69fdc4c18e      10 seconds ago       64.4MB

恭喜,应用容器化已经成功了!

读者可以通过 docker image inspect web:latest 来确认刚刚构建的镜像配置是否正确。这个命令会列出Dockerfile中设置的所有配置项。

4.推送镜像到仓库

在创建一个镜像之后,将其保存在一个镜像仓库服务是一个不错的方式。这样存储镜像会比较安全,并且可以被其他人访问使用。Docker Hub就是这样的一个开放的公共镜像仓库服务,并且这也是 docker image push 命令默认的推送地址。

在推送镜像之前,需要先使用Docker ID登录Docker Hub。除此之外,还需要为待推送的镜像打上合适的标签。

接下来本书会介绍如何登录Docker Hub,并将镜像推送到其中。

在后续的例子中,读者需要用自己的Docker ID替换本书中例子所使用的ID。所以每当读者看到“nigelpoulton”时,记得替换为自己的Docker ID。

$ docker login
Login with **your** Docker ID to push and pull images from Docker Hub...
Username: nigelpoulton
Password:
Login Succeeded

推送Docker镜像之前,读者还需要为镜像打标签。这是因为Docker在镜像推送的过程中需要如下信息。

  • Registry(镜像仓库服务)。
  • Repository(镜像仓库)。
  • Tag(镜像标签)。

读者无须为Registry和Tag指定值。当读者没有为上述信息指定具体值的时候,Docker会默认 Registry=docker.io、Tag=latest 。但是Docker并没有给 Repository 提供默认值,而是从被推送镜像中的 REPOSITORY 属性值获取。这一点可能不好理解,下面会通过一个完整的例子来介绍如何向Docker Hub中推送一个镜像。

在本章节前面的例子中执行了 docker image ls 命令。在该命令对应的输出内容中可以看到,镜像仓库的名称是web。这意味着执行 docker image push 命令,会尝试将镜像推送到 docker.io/web:latest 中。但是其实 nigelpoulton 这个用户并没有 web 这个镜像仓库的访问权限,所以只能尝试推送到 nigelpoulton 这个二级命名空间(Namespace)之下。因此需要使用nigelpoulton这个ID,为当前镜像重新打一个标签。

$ docker image tag web:latest nigelpoulton/web:latest

为镜像打标签命令的格式是 docker image tag <current-tag> <new-tag> ,其作用是为指定的镜像添加一个额外的标签,并且不需要覆盖已经存在的标签。

再次执行 docker image ls 命令,可以看到这个镜像现在有了两个标签,其中一个包含Docker ID nigelpoulton

$ docker image ls
REPO                TAG       IMAGE ID        CREATED       SIZE
web                 latest    fc69fdc4c18e    10 secs ago   64.4MB
nigelpoulton/web    latest    fc69fdc4c18e    10 secs ago   64.4MB

现在将该镜像推送到Docker Hub。

$ docker image push nigelpoulton/web:latest
The push refers to repository [docker.io/nigelpoulton/web]
2444b4ec39ad: Pushed
ed8142d2affb: Pushed
d77e2754766d: Pushed
cd7100a72410: Mounted from library/alpine
latest: digest: sha256:68c2dea730...f8cf7478 size: 1160

图8.6展示了Docker如何确定镜像所要推送的目的仓库。

41.png

图8.6 确定镜像所要推送的目的仓库

因为权限问题,读者需要把上面例子中出现的ID(nigelpoulton)替换为自己的Docker ID,才能进行推送操作。

在本章接下来的例子当中,本书将使用 web:latest 这个标签(两个标签中较短的一个)。

5.运行应用程序

前文中容器化的这个应用程序其实很简单,从 app.js 这个文件内容中可以看出,这其实就是一个在 8080 端口提供Web服务的应用程序。

下面的命令会基于 web:latest 这个镜像,启动一个名为 c1 的容器。该容器将内部的 8080 端口与Docker主机的 80 端口进行映射。这意味读者可以打开一个浏览器,在地址栏输入Docker主机的DNS名称或者IP地址,然后就能直接访问这个Web应用了。

注:

如果Docker主机已经运行了某个使用80端口的应用程序,读者可以在执行 docker container run 命令时指定一个不同的映射端口。例如,可以使用 -p 5000:8000 参数,将Docker内部应用程序的8080端口映射到主机的5000端口。

$ docker container run -d --name c1 \
  -p 80:8080 \
  web:latest

-d 参数的作用是让应用程序以守护线程的方式在后台运行。 -p 80:8080 参数的作用是将主机的80端口与容器内的8080端口进行映射。

接下来验证一下程序是否真的成功运行,并且对外提供服务的端口是否正常工作。

$ docker container ls
ID    IMAGE       COMMAND           STATUS      PORTS
49.  web:latest  "node ./app.js"   UP 6 secs   0.0.0.0:80->8080/tcp

为了方便阅读,本书只截取了命令输出内容的一部分。从上面的输出内容中可以看到,容器已经正常运行。需要注意的是,80端口已经成功映射到了8080之上,并且任意外部主机( 0.0.0.0:80 )均可以通过80端口访问该容器。

6.APP测试

打开浏览器,在地址栏输入DNS名称或者IP地址,就能访问到正在运行的应用程序了。读者可以看到图8.7所示的界面。

42.png

图8.7 正在运行的应用程序界面

如果没有出现这样的界面,尝试执行下面的检查来确认原因所在。

  • 使用 docker container ls 指令来确认容器已经启动并且正常运行。容器名称是 c1 ,并且从输出内容中能看到 0.0.0.0:80->8080/tcp
  • 确认防火墙或者其他网络安全设置没有阻止访问Docker主机的 80 端口。

如此,应用程序已经容器化并成功运行了,庆祝一下吧!

7.详述

到现在为止,读者应当成功完成一个示例应用程序的容器化。下面是其中一些细节部分的回顾和总结。

Dockerfile中的注释行,都是以#开头的。

除注释之外,每一行都是一条指令(Instruction)。指令的格式是指令参数如下。

INSTRUCTION argument

指令是不区分大小写的,但是通常都采用大写的方式。这样Dockerfile的可读性会高一些。

Docker image build 命令会按行来解析Dockerfile中的指令并顺序执行。

部分指令会在镜像中创建新的镜像层,其他指令只会增加或修改镜像的元数据信息。

在上面的例子当中,新增镜像层的指令包括 FROMRUN 以及 COPY ,而新增元数据的指令包括 EXPOSEWORKDIRENV 以及 ENTERPOINT 。关于如何区分命令是否会新建镜像层,一个基本的原则是,如果指令的作用是向镜像中增添新的文件或者程序,那么这条指令就会新建镜像层;如果只是告诉Docker如何完成构建或者如何运行应用程序,那么就只会增加镜像的元数据。

可以通过 docker image history 来查看在构建镜像的过程中都执行了哪些指令。

$ docker image history web:latest
IMAGE     CREATED BY                                       SIZE
fc6..18e  /bin/sh -c #(nop)  ENTRYPOINT ["node" "./a...    0B
334..bf0  /bin/sh -c #(nop)  EXPOSE 8080/tcp               0B
b27..eae  /bin/sh -c npm install                           14.1MB
932..749  /bin/sh -c #(nop) WORKDIR /src                   0B
052..2dc  /bin/sh -c #(nop) COPY dir:2a6ed1703749e80...    22.5kB
c1d..81f  /bin/sh -c apk add --update nodejs nodejs-npm    46.1MB
336..b92  /bin/sh -c #(nop)  LABEL maintainer=nigelp...    0B
3fd..f02  /bin/sh -c #(nop)  CMD ["/bin/sh"]               0B
 /bin/sh -c #(nop) ADD file:093f0723fa46f6c...    4.15MB

在上面的输出内容当中,有两点是需要注意的。

首先,每行内容都对应了Dockerfile中的一条指令(顺序是自下而上)。 CREATE BY 这一列中还展示了当前行具体对应Dockerfile中的哪条指令。

其次,从这个输出内容中,可以观察到只有4条指令会新建镜像层(就是那些SIZE列对应的数值不为零的指令),分别对应Dockerfile中的 FROMRUN 以及 COPY 指令。虽然其他指令看上去跟这些新建镜像层的指令并无区别,但实际上它们只在镜像中新增了元数据信息。这些指令之所以看起来没有区别,是因为Docker对之前构建镜像层方式的兼容。

读者可以通过执行 docker image inspect 指令来确认确实只有4个层被创建了。

$ docker image inspect web:latest
<Snip>
},
"RootFS": {
    "Type": "layers",
    "Layers": [
        "sha256:cd7100...1882bd56d263e02b6215",
        "sha256:b3f88e...cae0e290980576e24885",
        "sha256:3cfa21...cc819ef5e3246ec4fe16",
        "sha256:4408b4...d52c731ba0b205392567"
    ]
},

使用 FROM 指令引用官方基础镜像是一个很好的习惯,这是因为官方的镜像通常会遵循一些最佳实践,并且能帮助使用者规避一些已知的问题。除此之外,使用 FROM 的时候选择一个相对较小的镜像文件通常也能避免一些潜在的问题。

读者也可以观察 docker image build 命令具体的输出内容,了解镜像构建的过程。在下面的片段中,可以看到基本的构建过程是,运行临时容器>在该容器中运行Dockerfile中的指令>将指令运行结果保存为一个新的镜像层>删除临时容器。

Step 3/8 : RUN apk add --update nodejs nodejs-npm
 ---> Running in e690ddca785f    << Run inside of temp container
fetch http://dl-cdn...APKINDEX.tar.gz
fetch http://dl-cdn...APKINDEX.tar.gz
(1/10) Installing ca-certificates (20171114-r0)
<Snip>
OK: 61 MiB in 21 packages
 ---> c1d31d36b81f                << Create new layer
Removing intermediate container   << Remove temp container
Step 4/8 : COPY . /src

results matching ""

    No results matching ""